package com.googlecode.afdbaccess.web.common.servletfilter.sessionlesscookieauthentication;

import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.InvalidParameterSpecException;
import java.util.HashSet;

import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;

import org.apache.log4j.Logger;

import com.googlecode.afdbaccess.web.common.login.ApplicationPrincipal;

/**
 * Filter to make the app server use an encrypted cookie to keep login information, instead of using the
 * session. This means that the login survives between different instances of the server without them
 * having to be clustered and set to sync session data, and login also survives restarts, even if session
 * data is not persisted between restarts.
 * 
 * Uses the username/password login module configured by the web app config, by calling super.login to check
 * login information, when login() is called on the HttpServletRequest object provided by this filter.
 */
public class AuthenticationFilter implements Filter {
    private final static Logger log = Logger.getLogger(AuthenticationFilter.class);
    
    private ByteArrayCrypter objectCrypter;
    
    private String cookieName;
    private String cookieDomain;
    private String cookiePath;
    private boolean cookieSecure;
    private int loginValidForMinutes;
    private int cookieRefreshAtAgeInMinutes;

    class RequestWrapper extends HttpServletRequestWrapper {
        private final HttpServletResponse response;
        
        private ApplicationPrincipal principal = null;
        private HashSet<String> roles = null;

        public RequestWrapper(HttpServletRequest request, HttpServletResponse response) {
            super(request);

            this.response = response;
        }
        
        //
        
        private Cookie createBaseCookie() {
            Cookie cookie = new Cookie(cookieName, "");
            if (cookieDomain != null) {
                cookie.setDomain(cookieDomain);
            }
            cookie.setPath(cookiePath);
            cookie.setSecure(cookieSecure);
            cookie.setHttpOnly(true);

            return cookie;
        }
        
        private Cookie findLoginSessionCookie() {
            if (super.getCookies() != null) {
                for (Cookie cookie : super.getCookies()) {
                    if (cookie.getName().equals(cookieName)) {
                        return cookie;
                    }
                }
            }
            
            return null;
        }
        
        private void setCookie() throws ServletException {
            String value;
            
            try {
                value = objectCrypter.encrypt(new SerializeableUserInfo(principal, roles).toBytes());
            } catch (InvalidKeyException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (IllegalBlockSizeException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (BadPaddingException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (InvalidParameterSpecException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (NoSuchAlgorithmException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (NoSuchPaddingException exception) {
                throw new ServletException("Can't encrypt", exception);
            } catch (IOException exception) {
                throw new ServletException("Can't encrypt", exception);
            }
            
            Cookie cookie = createBaseCookie();
            cookie.setValue(value);
            cookie.setMaxAge(-1);
            response.addCookie(cookie);
        }
        
        private boolean readCookie() throws ServletException {
//            long startTime = System.currentTimeMillis();
//            
//            try {
                Cookie cookie = findLoginSessionCookie();
                
                if (cookie != null && cookie.getValue().equals("") == false) {
                    SerializeableUserInfo encryptedUserInfo = null;
                    
                    try {
                        try {
                            encryptedUserInfo = SerializeableUserInfo.fromBytes(objectCrypter.decrypt(cookie.getValue()));
                        } catch (NumberFormatException exception) {
                            log.debug("Couldn't decrypt", exception);
                            clearCookie();
                        } catch (InvalidKeyException exception) {
                            log.debug("Couldn't decrypt", exception);
                            clearCookie();
                        } catch (IllegalBlockSizeException exception) {
                            log.debug("Couldn't decrypt", exception);
                            clearCookie();
                        } catch (BadPaddingException exception) {
                            log.debug("Couldn't decrypt", exception);
                            clearCookie();
                        } catch (InvalidAlgorithmParameterException exception) {
                            throw new ServletException("Can't decrypt", exception);
                        } catch (NoSuchAlgorithmException exception) {
                            throw new ServletException("Can't decrypt", exception);
                        } catch (NoSuchPaddingException exception) {
                            throw new ServletException("Can't decrypt", exception);
                        } catch (IOException exception) {
                            log.debug("Couldn't decrypt", exception);
                            clearCookie();
                        }
                    } catch (ServletException exception) {
                        clearCookie();
                        throw exception;
                    }
                    
                    if (encryptedUserInfo != null && encryptedUserInfo.isFresh(loginValidForMinutes)) {
                        principal = encryptedUserInfo.getPrincipal();
                        roles = encryptedUserInfo.getRoles();
                        
                        // TODO: refresh cookie (embedded timestamp) if needed
                        if (encryptedUserInfo.isFresh(cookieRefreshAtAgeInMinutes) == false) {
                            setCookie();
                        }
                        
                        return true;
                    }
                }
                
                return false;
//            } finally {
//                long endTime = System.currentTimeMillis();
//                log.info("readcookie time: " + (endTime-startTime) + " ms.");
//            }
        }
        
        private void clearCookie() {
            Cookie cookie = createBaseCookie();
            cookie.setValue("");
            cookie.setMaxAge(0);
            response.addCookie(cookie);
        }
        
        //

        @Override
        public boolean authenticate(@SuppressWarnings("hiding") HttpServletResponse response) throws IOException, ServletException {
            if (principal != null) {
                return true;
            } else {
                boolean cookieWasValid = readCookie();
                
                if (cookieWasValid) {
                    return cookieWasValid;
                } else {
                    return super.authenticate(response);
                }
            }
        }

        @Override
        public String getAuthType() {
            if (principal != null) {
                return AuthenticationFilter.class.getName();
            } else {
                return null;
            }
        }

        @Override
        public String getRemoteUser() {
            if (principal != null) {
                return principal.getName();
            } else {
                return null;
            }
        }

        @Override
        public Principal getUserPrincipal() {
            return principal;
        }

        @Override
        public boolean isUserInRole(String role) {
            if (roles != null) {
                return roles.contains(role);
            } else {
                return false;
            }
        }

        @Override
        public void login(String username, String password) throws ServletException {
            if (principal == null) {
                try {
                    super.login(username, password);
                } catch (ServletException servletException) {
                    throw new ServletException("Wrong username or password.", servletException);
                }

                Principal origPrincipal = super.getUserPrincipal();
                
                principal = (ApplicationPrincipal)origPrincipal;

                // Having a hard coded role list is not great, but I think it is unavoidable?
                {
                    String[] allRoles = { "theRole" };
                    
                    roles = new HashSet<String>();
                    for (String role : allRoles) {
                        if (super.isUserInRole(role)) {
                            roles.add(role);
                        }
                    }
                }

                setCookie();
            } else {
                throw new ServletException("Already logged in.");
            }
        }

        @Override
        public void logout() throws ServletException {
            principal = null;
            roles = null;
            
            clearCookie();
            
            super.logout();
        }
    }
    
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        
        RequestWrapper requestWrapper = new RequestWrapper(request, response);
        requestWrapper.authenticate(response);
        
        chain.doFilter(requestWrapper, res);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        try {
            // Get cookie name as init parameter
            cookieName = filterConfig.getInitParameter("cookieName");
            if (cookieName == null || cookieName.trim().equals("")) {
                cookieName = "L";
            }
            log.debug("cookieName: " + cookieName);
            
            // Get cookie domain as init parameter
            cookieDomain = filterConfig.getInitParameter("cookieDomain");
            if (cookieDomain == null || cookieDomain.trim().equals("")) {
                cookieDomain = null;
            }
            log.debug("cookieDomain: " + cookieDomain);

            // Get cookie path as init parameter
            cookiePath = filterConfig.getInitParameter("cookiePath");
            if (cookiePath == null || cookiePath.trim().equals("")) {
                cookiePath = filterConfig.getServletContext().getContextPath();
            }
            log.debug("cookiePath: " + cookiePath);
            
            // Get cookie secure status
            {
                String cookieSecureString = filterConfig.getInitParameter("cookieSecure");
                if (cookieSecureString == null || cookieSecureString.trim().equals("")) {
                    cookieSecure = true;
                } else {
                    if (cookieSecureString.equalsIgnoreCase("true")) {
                        cookieSecure = true;
                    } else if (cookieSecureString.equalsIgnoreCase("false")) {
                        cookieSecure = false;
                    } else {
                        throw new ServletException("cookieSecure must be true, false, or not set.");
                    }
                }
            }
            log.debug("cookieSecure: " + cookieSecure);
            
            // Get login valid time as init parameter
            {
                String loginValidForMinutesString = filterConfig.getInitParameter("loginValidForMinutes");
    
                if (loginValidForMinutesString == null || loginValidForMinutesString.trim().equals("")) {
                    loginValidForMinutes = 2*60;
                } else {
                    try {
                        loginValidForMinutes = Integer.parseInt(loginValidForMinutesString);
                    } catch (NumberFormatException exception) {
                        throw new ServletException("loginValidForMinutes must be an integer.", exception);
                    }
                }
            }
            log.debug("loginValidForMinutes: " + loginValidForMinutes + " minutes");
            
            // Get time for how old cookie is allowed to be before it is refreshed with the current request (that is, timestamp inside it is set to now) as init parameter
            cookieRefreshAtAgeInMinutes = (int) Math.ceil(loginValidForMinutes / 2);
            if (cookieRefreshAtAgeInMinutes >= loginValidForMinutes) {
                cookieRefreshAtAgeInMinutes = loginValidForMinutes-1;
            }
            if (cookieRefreshAtAgeInMinutes > 60) {
                cookieRefreshAtAgeInMinutes = 60;
            }
            if (cookieRefreshAtAgeInMinutes < 0) {
                cookieRefreshAtAgeInMinutes = 0;
            }
            log.debug("cookieRefreshAtAgeInMinutes: " + cookieRefreshAtAgeInMinutes + " minutes");
            
            // Get encryption key as init parameter
            {            
                String password = filterConfig.getInitParameter("encryptionKey");
                if (password == null || password.trim().equals("")) {
                    throw new ServletException("init parameter \"encryptionKey\" must be set.");
                }
    
                // Init the encrypter with the password
                try {
                    objectCrypter = new ByteArrayCrypter(password);
                } catch (InvalidKeySpecException exception) {
                    throw new ServletException(exception);
                } catch (NoSuchAlgorithmException exception) {
                    throw new ServletException(exception);
                }
            }
        } catch (ServletException exception) {
            log.error("init failed", exception);
            throw exception;
        }
    }

    @Override
    public void destroy() {
        // do nothing
    }
}